Create a simple theme switcher with custom preset colors

Some users prefer their themes light, some dark, this might differ depending on the room’s brightness or time of day. Why not enhance your app with a brand new theme switcher to meet their needs? Today, we’ll put together a simple component that will offer your users a way to select between dark and light mode, additionally we’ll provide a small range of color presets to choose from. Clone the blog repo or spin up a new Phoenix project with mix phx.new(just remember to change MyBlogWeb with the name of your app) and let’s dive right in.

This article explains how to implement a theme switcher.

Contents

  1. Adding custom colors
  2. Creating a header
  3. Assembling the theme switcher
  4. CSS media query script
  5. Client hook
  6. Visualization

Adding custom colors

The first step in our course of action, is to setup a collection of colors. Open up app.css in assets/css/ directory and create new selector :root. Here we’ll add a variable for height of our header, which we’ll be building in the upcoming section.

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

/* This file is for your main application CSS */
:root {
  --headerH: 5rem;
}

The whole idea behind the preset switcher is based on html attributes. Since we can add any attribute we like, we’ll use javascript to modify them on the html element. When the user changes the color, css will apply adequate styles. We can easily achieve that by matching attribute selector e.g. :root[data-theme="name_of_our_preset"]. So, with this explained, let’t start creating our styles. I’ll do two colors in light and dark modes, but you can keep on racking them up as much as you like. We’ll prepare a base color, an accent, a background color, a second background color(for building headers, footers etc), text and alternate text colors. All of the variables will be in HSL format, notice that we are inputing only percentage values and remember to keep all the variable’s names the same in every preset.

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

/* This file is for your main application CSS */
:root {
  --headerH: 5rem;
}
:root[data-theme="blue"] {
  --accent: 225 100% 60%;
  --bg: 0 0% 95%;
  --bgUi: 0 0% 0%;
  --text: 0 0% 5%;
  --textUi: 0 0% 95%;
}  
:root[data-theme="blue-dark"] {
  --accent: 225 100% 60%;
  --bg: 0 0% 7.5%;
  --bgUi: 0 0% 0%;
  --text: 0 0% 95%;
  --textUi: 0 0% 95%;
}  
:root[data-theme="green"] {
  --accent: 90 100% 30%;
  --bg: 0 0% 95%;
  --bgUi: 0 0% 0%;
  --text: 0 0% 5%;
  --textUi: 0 0% 95%;
}  
:root[data-theme="green-dark"] {
  --accent: 90 100% 30%;
  --bg: 0 0% 7.5%;
  --bgUi: 0 0% 0%;
  --text: 0 0% 95%;
  --textUi: 0 0% 95%;
}                   

Now, open the tailwind.config.js file located in assets/ directory, you’ll see the theme property there. We’ll now add the variables to the configuration file, that we introduced in app.css. Add them in accordance to the code block provided below, in the theme.extend.height and theme.extend.colors properties of the module export:

theme: {
    extend: {
      height:{
        header: "var(--headerH)"
      },
      colors: {
        accent: "hsl(var(--accent) / <alpha-value>)",
        bg: "hsl(var(--bg) / <alpha-value>)",
        "bg-ui": "hsl(var(--bgUi) / <alpha-value>)",
        text: "hsl(var(--text) / <alpha-value>)",
        "text-ui": "hsl(var(--textUi) / <alpha-value>)",
        brand: "#FD4F00",
      }
    },
  },

Creating a header to hold the theme switcher

Nice, with variables set up, we can now begin to create a component that will hold our switcher. In web developement, it is a widely adopted practice to place it in the page’s header, so let’s do just that. In lib/my_app_web/components directory, create a new folder header, inside it, header.ex and theme_swither.ex files. Let’s begin our work on the header by defining the module and injecting the code of live component by typing use MyBlogWeb, :live_component. After this, we need to implement two live_component callbacks: update/2 - responsible for handling the component’s state and render/1 - this will render our html with the help of ~H sigil. We will also place here our ThemeSwitcher component, which we’ll build next.

defmodule MyBlogWeb.Header.Header do
  use MyBlogWeb, :live_component

  def update(assigns, socket) do
    {:ok, socket |> assign(assigns)}
  end

 def render(assigns) do
    ~H"""
   <header class="h-header relative">
      <div class="h-header fixed w-full bg-bg-ui/90 text-text-ui z-10 px-8 flex gap-2 items-center">
        <nav class="flex justify-start items-center w-full gap-2">
          <div>
            <.link navigate={"/"} class="px-6 rounded-sm py-2 hover:bg-accent text-2xl font-bold transition">My Blog</.link>
          </div>
          <div>
            <.link navigate={"/blog"} class="px-6 rounded-sm py-2 hover:bg-accent text-xl font-semibold transition">Blog</.link>
          </div>
        </nav>
        <.live_component module={MyBlogWeb.Header.ThemeSwitcher} id="theme_switcher" />
      </div>
    </header>
    """
  end
end

Assembling the theme switcher

We added our custom height and colors to text, background and hover transitions within the header, get ready to build the markup and logic for the switcher. Open theme_switcher.ex and repeat the steps from previous component, that is, define the module, inject the live component code, define update/2 and render/1 callbacks. In the mount/1, we’ll create options list containing names of our themes, return a standard liveview tuple with an :ok atom and pass couple of assigns to the socket. The first one will be component id, second will be our list of themes and last one will control whether the list is visible to the user or not.

defmodule MyBlogWeb.Header.ThemeSwitcher do
  use MyBlogWeb, :live_component

  def update(assigns, socket) do
    options = ["blue", "blue-dark", "green", "green-dark"]
    {:ok, socket |> assign(:id, assigns.id) |> assign(:options, options) |> assign(:show, false)}
  end
end

In the render/1, build a div that will encapsulate our theme switcher, along with a button, to show or hide it, depending on the state of show boolean. The phx-target={@myself} annotation specifies, that the event emited by the button will reach the component instead of an active liveview process(as per docs). We’ll also add a phoenix binding phx-click={if !@show, do: "open_theme"}, it will reside within the button and be responsible for showing the select element, Additionally, phx-click-away will be placed within the div containing the select tag, assigning false to show variable, whenever the user clicks somewhere beyond the div’s border. And finally, we’ll provide a phx-hook binding to the select element, this will run our logic that takes care of changing the html attributes.

def render(assigns) do
    ~H"""
      <div class={"flex items-center justify-end gap-2 rounded-lg #{if @show, do: "bg-accent w-60", else: "bg-bg w-0"} transition-all"}>
        <.button
          class={"order-2 group hover:ring-2 ring-accent #{if @show , do: "ring-2"} transition"}
          aria-label="toggles the theme selector"
          type="button"
          phx-target={@myself}
          phx-click={if !@show, do: "open_theme"}
        >
        🎨
        </.button>
        <%= if @show do %>
          <div
            class="order-1 top-12 right-0 p-1 rounded-md"
            phx-click-away="close_theme"
            phx-target={@myself}
          >
            <label class="invisible" for={"theme_switcher_select"} aria-label="theme selector label"></label>
            <select
              aria-label="theme selector"
              id={"theme_switcher_select"}
              class="group-hover:bg-accent text-text rounded-md text-sm p-1 w-[20ch] bg-bg transition"
              phx-hook="HandleTheme"
            >
              <%= for o <- @options do %>
                <option value={o}>
                  <%= o %>
                </option>
              <% end %>
            </select>
          </div>
        <% end %>
      </div>
    """
  end

So far so good. We placed some event emiters in the previous paragraph. Time to handle them in our component. We simply use handle_event/3, callback, pattern match incoming event to open_theme and call assign/3 passing in the socket, atom :show and true. We do the same for close_theme event and assign false to the socket in similar fashion.

  def handle_event("open_theme", _unsigned_params, socket) do
    {:noreply, socket |> assign(:show, true)}
  end

  def handle_event("close_theme",_params, socket) do
    {:noreply, socket |> assign(:show, false)}
  end

Our component is almost ready, we can now wire it up. Open app.html.heex in lib/my_app_web/components/layouts/, delete the <header> element and replace it with our live_component:

<.live_component module={MyBlogWeb.Header.Header} id="header" />
<main class="px-4 py-20 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    {@inner_content}
  </div>
</main>

CSS media query script

Browsers have different ways of managing data, one of them is localStorage, it enables developers to keep data in a key-value pair format. We’ll use it to store user’s choosen theme, uihough it is not recommended to put there any important or critical information as it is easily accessible. Whenever a new user visits our site or they deleted the theme value manually, we have to detect which color scheme they favour and set a new theme accordingly. By using window.matchMedia javascript function and prefers-color-scheme CSS media query, we can easily achieve this. Let’s create a new file in priv/static/assets/, name it theme.js and use setAttribute to add a new attribute with a name of one of our presets as a value to the html element.


if(localStorage.theme){
     document.querySelector("html").setAttribute("data-theme", localStorage.theme)
} else {
    if(window.matchMedia("(prefers-color-scheme: dark)")){
         document.querySelector("html").setAttribute("data-theme", "blue-dark")
    } else {
         document.querySelector("html").setAttribute("data-theme", "blue")
    }
}

In order for the script to run we need to add it to the root.html.heex in lib/my_blog_web/components/layouts/ directory. Just place it at the end(but still within) of the head element.

 <script type="text/javascript" src={~p"/assets/theme.js"}>
    </script>

We also must add the script file to the .gitignore, as everything inside priv/static/assets/ directory is automatically excluded by git. Simply add a *(asterisk) sign at the end of /priv/static/assets/ entry and the following line bellow it(this is important!) in the .gitingore file:

/priv/static/assets/*
!/priv/static/assets/theme.js

Client hook

Great, with previous steps completed we may attach a new hook to the LiveSocket in assets/js/app.js. Open it and create the Hooks object, just below the imports at the top of the file. Now add a new hook called HandleTheme and place it within the mounted callback, it will run after the element it is attached to is added to the DOM, and after the liveview process that renders it has finished mounting. We are going to grab the select element we whiped up earlier and the theme value from localStorage, we’ll check whether the select element is rendered on the page, if it’s not, we’ll just stop there, if it is, we’ll continue. We’ll loop over options and check whether the theme value is contained there. When we get a match, we’ll call setAttribute with selected and true as arguments. Next we’ll add a new event listener to every option element and provide a callback, that will set the data-theme attribute on the html element with the value of an element that initiated the event. Lastly, the function will save the theme in localStorage.

let Hooks = {}
Hooks.HandleTheme = {
  mounted() {
    const select = document.getElementById("theme_switcher_select")
    const html = document.querySelector("html")
    let theme = localStorage.getItem("theme")
  
    if(select !== null) {
      Array.from(select.options).map(o => {
        if(o.value === theme){
          o.setAttribute("selected", true)
        }
      })
       
      select.addEventListener("input", (e) => {
        html.setAttribute("data-theme", e.target.value)
        localStorage.setItem("theme", e.target.value)
      })
    }
  }
}

Before we move further we have to pass our Hooks object to the liveSocket:

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks,
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken}
})

Visualization

Time to use our header. Create live folder in lib/MyBlogWeb/, within, we’ll place a new liveview file home_live.ex and construct a simple markup returning <h1> tag.

defmodule MyBlogWeb.HomeLive do
  use MyBlogWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <div class="flex justify-center">
      <h1 class="text-xl font-semibold ring-2 ring-accent p-2 rounded-md">My Blog</h1>
    </div>
    """
  end
end

Let’s add it to the router.ex file, so our app knows what to render when we navigate to “/“ path. Replace the get with the live macro, providing a path “/“ and our HomeLive module in the default scope "/":

    live "/", HomeLive

Head to root.html.heex layout in lib/my_blog_web/components/layouts/ and replace the classes on body element with bg-bg text-text, this will set the colors of text and background in our entire site.

If you’ve cloned the blog repo, you may go to other liveview files and the createAndAddCopyButton function in app.js to change the styles, so they’re incorporating the new system we just implemented.

Now you can entertain your users with a simple theme switcher that assigns color schemes depending on their preferences. Additionally, it keeps track of selected presets locally. 🎉

image

Additional content:

0 comments